Skip to content

Vercel AI SDK 记录

这里主要记录 Vercel AI SDK 的两个底层机制:Data Stream Protocol 解决 streaming + tool calling + metadata 的单连接多路复用问题;Provider Adapter 抽象LanguageModelV2 接口)负责统一不同 LLM provider 的接入方式。上层 API 如 useChatstreamTextstreamObject,本质上都是建立在这两层之上的封装。

useChat 到底做了什么

表面上看 useChat 就是个带状态的 fetch wrapper,实际上它内部维护了一个相当完整的状态机。

核心流程大致是:用户提交消息 → hook 把消息 append 到本地 messages 数组(乐观更新)→ 发 POST 到 /api/chat → 后端 streamText() 返回 ReadableStream → 客户端逐 chunk 解析 → 更新最后一条 assistant 消息的 content。

真正需要关注的不是这条标准链路,而是几个边界情况:

  • Abort: hook 内部用 AbortController 管理请求。用户发新消息时,上一个流式响应会被 abort 掉。这避免了竞态——旧响应还在 stream 但 UI 已经切换到新对话。
  • 错误恢复: onError 回调能拿到 error 对象,但实际生产里更麻烦的是 stream 中断到一半——前端拿到半条 assistant 消息,后端已经 stream 完了,怎么处理?SDK 默认是保留那半条消息,让用户手动重试。这个行为在 onResponseonFinish 之间有不少坑。
  • 实验性功能: experimental_prepareRequestBody 这个 option 很少有人提,但如果你需要在请求体里注入额外的 context(比如 session id、user metadata),就只能靠它。

streamText 的返回格式

streamText() 返回的 toDataStreamResponse() 用的是 Vercel 自定义的 data stream protocol,不是 SSE。跟你平时用的 text/event-stream 不一样。

每个 chunk 的格式类似:

text
0:"Hello"
2:{"tool_calls":[{"type":"function","function":{"name":"get_weather","arguments":"{"city":"Beijing"}"}}]}
d:{"finishReason":"stop"}
  • 0: 前缀 → 文本 delta
  • 2: 前缀 → tool call 的 JSON 序列化结果
  • d: 前缀 → 数据(finish reason, usage 等)
  • 3: → tool result
  • e: → 错误
  • 9: → 自定义 metadata

useChat 那端有个解析器,根据前缀把 chunk 分派到不同的 handler。遇到 0: 就 append 到当前 assistant 消息,遇到 2: 就更新 tool_call 状态,遇到 d: 里的 finishReason 就标记对话完成。

这个协议比普通 SSE 更适合 SDK 当前的场景:一个 stream 里可以同时传文本、tool call、metadata,不需要额外拆多个 event type。

但在生产里也遇到过解析失败的情况,一般是后端 middleware 意外修改了响应流(比如某个 Next.js middleware 加了 header 导致 chunk 被截断或者被 gzip 合并)。

Provider 抽象层

Vercel AI SDK 更像一个 provider-agnostic 的 adapter 框架,而不只是单纯的 AI library。

LanguageModelV2 接口定义了 provider 需要实现的契约:

ts
interface LanguageModelV2 {
  specificationVersion: 'v2';
  provider: string;
  modelId: string;
  doGenerate(options): Promise<GenerateResult>;
  doStream(options): Promise<StreamResult>;
}

每个 provider(OpenAI、Anthropic、Google、Mistral...)只需实现 doGeneratedoStream。上层 streamText / generateText / streamObject 完全不知道底层是什么模型,全部通过 adapter 调用。

这个设计的实际价值在于切换模型不需要改业务代码。从 openai('gpt-4o') 切到 anthropic('claude-sonnet-4-6') 就是换一个函数调用,prompt、tool definition、stream handling 全都一样。

实际落点在 prompt 兼容性上。同一个 system prompt,GPT 和 Claude 的理解方式可能并不一致,直接切模型并不等于可以无成本复用行为。

Tool Calling 的执行模型

Vercel AI SDK 的 tool calling 跟 LangChain 的 agent loop 不是一回事。SDK 这边更"声明式"——你定义 tools,模型决定调用哪个,SDK 执行 tool,把结果发回模型。

maxToolRoundtrips 控制最大 tool→result→model→tool 的轮次数,默认是 1。调成 3 或 5 可以让模型多步推理,但成本翻倍而且容易循环。

一个实际的坑:tool 执行失败时(比如调用外部 API 超时),SDK 的行为取决于你的 tool 实现。如果 tool 直接 throw,SDK 默认会把这个 error 传给 onError,stream 中断。更好的做法是 tool 返回 error string 作为结果,让模型自己决定怎么处理。

生产环境里的几个实际问题

Stream 中断处理: 用户网络抖动、切后台、VPN 切换都可能导致 stream 中断。useChatreload 方法可以重发最后一条消息,但重发意味着重新调用模型——成本。所以一般会在 onFinish 里缓存完成的对话到 localStorage,前端在 onError 里先检查缓存再决定是否 reload。

多 Tab 同步: 用户开多个 tab,每个 tab 一个 useChat 实例往同一个 conversation 发消息,后端的 messages 数组就对不齐了。SDK 自己不解决这个问题,需要业务层用 id 做幂等。

Token 计数和成本控制: SDK 在 onFinish 里会返回 usage 对象(prompt tokens + completion tokens),但这个数据在某些 provider 下是 undefined(比如某些 Ollama 部署)。另外 streamText 模式下 token 计数是 streaming 完成之后才有的——如果想在 stream 中间中断长回复,需要在 onToken 里做字数计数来近似判断。

Edge Runtime 限制: Vercel Edge 上跑 streamText 有 30s 超时和 4MB 响应体限制。长对话超出上下文窗口或者 tool calling 多轮的话很容易碰到。

跟 LangChain 的取舍

LangChain 的重心在 chain/agent 编排,Vercel AI SDK 的重心在 streaming UX 和 provider 抽象。如果你主要做 chatbot 类产品,SDK 更轻、更容易上生产;如果做复杂的 multi-agent workflow,LangChain 的抽象层(虽然臃肿)至少覆盖了更多场景。

Vercel AI SDK 近几个版本也明显在往 agent 方向靠拢。generateText 配合 maxToolRoundtrips,已经可以组成一个基础的 agent loop,只是没有 LangChain 那么多内置的 memory、planning、reflection 策略。


源码级分析

1. 调用链追踪:从 useChat 到 LLM Provider

拆一条完整的请求链路:

text
[Client] useChat.handleSubmit()
  └─> appendMessageToUI(msg)          // 乐观更新, setMessages(prev => [...prev, userMsg, assistantMsg])
  └─> chatRequest(messages, options)  // 核心请求函数
        └─> fetch('/api/chat', { body: JSON.stringify({ messages }) })
        └─> response.body.getReader()  // 拿到 ReadableStream reader
        └─> readDataStream(reader)     // 逐行解析 stream chunk
              ├─> onChunk('0:', text)     // → appendTextToLastAssistantMessage()
              ├─> onChunk('2:', json)     // → updateToolCalls()
              ├─> onChunk('d:', data)     // → processFinishReason()
              ├─> onChunk('e:', error)    // → onError callback
              └─> onChunk('9:', meta)     // → custom handler

[Server] POST /api/chat
  └─> streamText({ model, messages, tools })
        └─> model.doStream({ messages, tools, ... })  // 调用 provider adapter
              └─> fetch(providerApiUrl, { body: chatCompletionRequest })  // 调用 OpenAI/Anthropic API
              └─> response.body → ReadableStream → AsyncIterator<LanguageModelV2StreamChunk>
        └─> DataStreamWriter.write(chunk)   // 编码为 data stream protocol
        └─> writer.pipeTo(response.body)    // 写入 HTTP Response Stream

这条链路里有两个关键的异步流:

  • Server side: LLM API 返回的 SSE stream → provider adapter 归一化为 LanguageModelV2StreamChunkDataStreamWriter 编码为 0:/2:/d: 前缀协议 → HTTP Response Stream
  • Client side: ReadableStream reader → readDataStream 解析器 → 前缀分派 → React setState

每个环节的背压(backpressure)通过 ReadableStream 的 pull-based 模型自然传递。如果客户端读得慢,服务端的 pipeTo 会使 LLM API 的 stream reader 暂停,往上传递到 provider 的 TCP socket。这意味着整个链路不存在内部 buffer 溢出问题——除非有人在中间插入一个 unbounded buffer(比如某些反向代理)。

2. readDataStream 解析器的内部实现

这是 ai 包里最重要的一个函数,我读源码时做了简化重构:

ts
async function readDataStream(
  reader: ReadableStreamDefaultReader<Uint8Array>,
  callbacks: {
    onText?: (text: string) => void;
    onToolCall?: (toolCall: ToolCall) => void;
    onData?: (data: DataMessage) => void;
    onError?: (error: unknown) => void;
  }
) {
  const decoder = new TextDecoder();
  let buffer = '';
  
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    
    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    // 最后一行可能不完整,保留到下次循环
    buffer = lines.pop() || '';
    
    for (const line of lines) {
      if (!line.trim()) continue;
      
      const type = line[0];             // '0' | '2' | 'd' | 'e' | '3' | '9'
      const colonIndex = line.indexOf(':');
      const content = line.slice(colonIndex + 1);
      
      switch (type) {
        case '0': callbacks.onText?.(JSON.parse(content)); break;
        case '2': callbacks.onToolCall?.(JSON.parse(content)); break;
        case 'd': callbacks.onData?.(JSON.parse(content)); break;
        case 'e': callbacks.onError?.(JSON.parse(content)); break;
        // ...
      }
    }
  }
}

关键点:buffer 拼接逻辑。 网络层传过来的 chunk 可能在任意位置分割,一行完整的 chunk(如 0:"Hello world")可能被 TCP 拆成两个 Uint8Array。解析器用 \n 做分隔符,不完整的最后一行保留在 buffer 里等下一个 chunk 拼接。这是所有流式协议解析器的标准做法。

一个容易出问题的地方:如果 LLM 返回的文本里包含换行符 \n,这个解析器会出错。解决办法是 JSON.stringify 之后的 content 不会有未转义的换行符——JSON.parse(content) 会自动还原。这是为什么 data stream protocol 选择 JSON 编码而不是 raw text。

3. useChat 的状态机

useChat 内部的状态流转可以抽象为 5 个状态:

text
IDLE ──handleSubmit()──> SUBMITTED ──response received──> STREAMING
  ^                                                          │
  │                                                          │ onFinish / error
  └──────────────────────────────────────────────────────────┘

                                                         READY (可继续输入)

但实际上还有一个隐藏状态:ABORTING。 当用户在新的 stream 进行中发送消息时:

text
STREAMING ──new handleSubmit()──> ABORTING ──abort() resolve──> SUBMITTED'

hook 内部维护的 key 变量:

  • abortControllerRef:当前请求的 AbortController
  • messages:所有消息的数组(包含 user + assistant + tool 消息)
  • isLoading:是否有进行中的请求
  • error:最近一次错误(不清空,手动 clear)

这里有个细节:messagesuseState 管理的,但 chatRequest 函数内部用的是闭包捕获的 messages(通过 messagesRef 或者直接传参)。如果hook 的 render 频率高,闭包里的 messages 可能是旧的。Vercel 的做法是 useChat 用一个 useRef 来保存最新的 messages,chatRequest 读取 ref.current

4. Provider Adapter 的接口契约

每个 provider adapter 实现的核心接口:

ts
interface LanguageModelV2 {
  specificationVersion: 'v2';
  provider: string;
  modelId: string;
  
  doGenerate(options: {
    mode: { type: 'regular' | 'object-json' | 'object-tool'; schema?: JSONSchema };
    prompt: LanguageModelV2Prompt;
    maxTokens?: number;
    temperature?: number;
    tools?: Tool[];
    toolChoice?: 'auto' | 'none' | 'required' | { type: 'tool'; toolName: string };
    headers?: Record<string, string>;
  }): PromiseLike<{
    text?: string;
    toolCalls?: ToolCall[];
    finishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' | 'unknown';
    usage: { promptTokens: number; completionTokens: number; totalTokens: number };
    rawResponse?: { headers?: Record<string, string> };
  }>;
  
  doStream(options: SameAsAbove): PromiseLike<{
    stream: ReadableStream<LanguageModelV2StreamPart>;
    rawResponse?: { headers?: Record<string, string> };
  }>;
}

streamText 调用 doStream,拿到 ReadableStream<LanguageModelV2StreamPart>,然后用自己的 DataStreamWriter 把每个内部 chunk type 映射到 data stream protocol 的前缀。

Provider 适配的关键在这里:不同 LLM API 返回的 SSE 格式完全不同。 OpenAI 返回 data: {"choices":[{"delta":{"content":"Hello"}}]}\n\n,Anthropic 返回的是 data: {"type":"content_block_delta","delta":{"text":"Hello"}}\n\n。Provider adapter 的工作就是把这些异构格式归一化为 LanguageModelV2StreamPart

ts
type LanguageModelV2StreamPart =
  | { type: 'text-delta'; textDelta: string }
  | { type: 'tool-call'; toolCallId: string; toolName: string; args: string }
  | { type: 'tool-result'; toolCallId: string; toolName: string; result: unknown }
  | { type: 'finish'; finishReason: string; usage: ... }
  | { type: 'error'; error: unknown };

上层代码完全不用关心是 GPT 还是 Claude。

5. 调度模型:为什么没有 React Scheduler / Vue Scheduler

React 自己有一套调度(Scheduler + Lane + concurrent mode),Vue 有异步队列(nextTick + scheduler),但 Vercel AI SDK 的 streaming 更新不经过这些调度层

原因:streaming 的每个 chunk 是通过 reader.read() 的 microtask 链传播的。每读到一行 chunk → 立即调 setState → React 在当前 task 结束后批量渲染。这是一个自然的异步链:

text
microtask: reader.read() resolve
  → microtask: setState
    → microtask: React re-render (batched by React internally)
      → microtask: next reader.read()

整个链路不依赖任何显式调度。setState 的频率取决于 LLM 吐 token 的速度(通常 30-80 tokens/s),远低于 React 的 60fps 渲染上限,所以不存在"更新太频繁导致掉帧"的问题。

有一个调度隐患:如果 LLM 吐得特别快(比如本地模型 >200 tokens/s),setState 会在同一个 event loop tick 内多次被调用。React 18+ 的 automatic batching 会把同一个 event handler 内的多次 setState 合并,但 streaming 的 reader.read() 每个 chunk 属于不同的 microtask,automatic batching 不一定生效。Vercel 的做法是内部维护一个 throttle(默认 50ms),超过这个间隔才触发一次 setState,把多个 chunk 合并到一次渲染。

6. 内存模型:消息列表的引用链

text
useChat.messages: Message[]
  ├─ [0] Message { id, role: 'user', content: '...' }
  ├─ [1] Message { id, role: 'assistant', content: '...' }
  ├─ [2] Message { id, role: 'user', content: '...' }
  └─ [3] Message { id, role: 'assistant', content: '...', toolInvocations: [...] }

每条 Message 是一个 plain object。Streaming 期间,最后一条 assistant 消息的 content 字段每次 chunk 都被替换(新字符串)。React 的 setState 触发 UI 重新渲染,但旧 content 字符串被 GC 回收。

潜在的内存问题在长对话:如果用户持续对话 200+ 轮,messages 数组不断 append,每条消息的 content 可能上千字符。200 轮 × 平均 500 字符/条 = 100KB。看起来不大,但 React 的 useState 会在每次 setState 时创建新数组(不可变更新),streaming 阶段每秒 30-80 次 setState → 每秒 3-8MB 的临时数组分配。这些临时对象会在当前渲染周期结束后被 GC,但在低端移动设备上,频繁 GC 会导致 UI 卡顿。

这也是为什么 Vercel AI SDK 建议在生产中对 messages 做截断——不是 token 超限问题,而是内存和 GC 压力。

7. Tool Calling 的轮次执行流程

带 tool calling 的 streamText 内部执行流程比普通 chat 复杂得多:

text
streamText({ model, messages, tools, maxToolRoundtrips: 3 })

  ├─ Round 1
  │   ├─ model.doStream({ messages, tools })
  │   ├─ stream chunks → client (text + tool_calls)
  │   └─ model 返回 tool_calls: [{ get_weather, { city: "Beijing" } }]

  ├─ Round 2
  │   ├─ 执行 tool: get_weather({ city: "Beijing" }) → "25°C, 晴"
  │   ├─ 构造新 messages: [...old, assistant(tool_calls), tool(result)]
  │   └─ model.doStream({ messages: newMessages, tools })

  └─ Round 3
      └─ model 返回 final text (no tool_calls) → stream 结束

核心点:每轮 tool calling 都是一次全新的 model.doStream() 调用。 这意味着每轮都会重新发送整个 messages 数组(包含前面所有的 user + assistant + tool 消息)给 LLM API。如果 maxToolRoundtrips: 5,messages 在 5 轮调用中不断膨胀,token 消耗几乎是线性增长。

这也是 maxToolRoundtrips 默认值为 1 的原因——超过 1 轮的成本很高,且容易陷入 tool→model→tool→model 的循环。

8. 复杂度分析

操作时间复杂度说明
useChat.handleSubmitO(1)追加一条消息 + 发起 fetch
readDataStream 解析O(n) n=chunk数量每个 chunk 一次正则/前缀匹配
setState 更新O(m) m=messages数量React 的不可变更新,全量替换数组引用
streamText 单轮O(t+p) t=tokens p=promptLLM 推理的时间复杂度取决于 token 数
Tool Calling N 轮O(N×(t+p))每轮重新发送完整 messages 数组
Provider 切换O(1)adapter 模式,无运行时开销

浏览器运行时分析

1. fetch + ReadableStream:浏览器如何消费流式响应

useChat 发起的是一个标准的 fetch() 请求,关键参数是 response.body

ts
const response = await fetch('/api/chat', {
  method: 'POST',
  body: JSON.stringify({ messages }),
});
const reader = response.body.getReader();

response.body 是一个 ReadableStream<Uint8Array>。在浏览器里,fetch 收到第一个字节后 response 就 resolve 了——不需要等完整响应体。这是 Streaming 能在浏览器工作的基础。

getReader() 返回一个 ReadableStreamDefaultReader,调用 reader.read() 返回 { done: boolean, value: Uint8Array }。这个方法返回 Promise,意味着每次 read() 都进入 microtask 队列。

浏览器内部,fetch 的数据流经以下路径:

text
Network Process (收到 TCP 数据)
  └─> HTTP Parser (解析 chunked transfer-encoding 或 raw stream)
      └─> DataPipe (跨进程传递,IPC)
          └─> Renderer Process (JS 引擎所在的进程)
              └─> Fetch API (构造 ReadableStream)
                  └─> reader.read() → microtask → JS callback

从网络包到 JS 回调至少要经过一次进程间通信(IPC)。如果浏览器使用了 site isolation,这个 IPC 延迟在 0.5-2ms 左右。LLM streaming 的 token 速率是 30-80/s,相当于每个 chunk 间隔 12-33ms,IPC 延迟完全可忽略。

但有一个值得注意的点:reader.read() 不会因为网络就绪就立即 resolve。 read() 在以下两种情况下 resolve:

  1. 内部 buffer 中有可用数据(至少 1 byte)
  2. stream 被关闭(done: true)

Chrome 的 fetch ReadableStream 内部 buffer 约 64KB。LLM API 返回的每个 token 只有几个字节,buffer 大部分时间几乎是空的,所以 read() 的行为接近于"每收到几个 token 就 resolve 一次"。具体 chunk 大小取决于 Nagle 算法、TCP 合并、以及 LLM API 端的 flush 策略。

2. Event Loop:Stream Chunk 与 React setState 的交互

每个 streaming chunk 带来的事件链:

text
Task: Network data arrives (IPC)
  └─> microtask: reader.read() resolves
      └─> microtask: chunk 解析 → setState()
          └─> microtask: React schedules re-render (SyncLane)
              └─> microtask: React commit → DOM 更新
                  └─> 浏览器:requestAnimationFrame 前,可能触发 layout/paint

这里的关键问题:每个 chunk 都是一次完整的 React 渲染周期。 如果 LLM 吐 50 tokens/s,每秒就有 50 次 setState → 50 次 React render → 50 次 DOM 更新。React 18 的 automatic batching 只针对同一个 microtask 内的 setState——不同的 read() microtask 之间不会合并。

Vercel AI SDK 内部用一个简单的 throttle(~50ms 间隔)来处理这个:超过 50ms 才 dispatch 一次 setState,把期间积压的 chunk 合并。

但即使合并到每 50ms 一次,也是 20 次/秒的渲染频率。对于 text-only 更新(只改文本节点),这个开销通常是可接受的(React 的 diff 在文本节点上是 O(1) 的)。但如果聊天 UI 有复杂的 markdown 渲染(code highlight、表格、mermaid 图表),每次渲染都要重新 parse → AST → highlight → DOM,成本可能超过 50ms,导致掉帧。

requestAnimationFrame 角度: 浏览器每 16.6ms 执行一次 rAF → style → layout → paint → composite。如果 React 在 rAF 之间做了 3 次 setState,浏览器只会在下一次 rAF 时渲染一次——中间的 DOM 变化不会产生额外的 layout/paint。这是浏览器的自然合并机制。但如果 setState → React render 本身耗时 >16.6ms,就会导致 rAF 延迟或跳过。

3. TextDecoder 的 Stream 模式

readDataStream 解析器里用到了 TextDecoder

ts
const decoder = new TextDecoder();
buffer += decoder.decode(value, { stream: true });

{ stream: true } 参数告诉 TextDecoder:这个 Uint8Array 可能是不完整的 UTF-8 序列,不要把末尾的无效字节替换为 � (U+FFFD),而是保留在内部 buffer 里等下次 decode。

UTF-8 字符可能由 1-4 个字节组成。如果 TCP chunk 恰好在 multi-byte 字符中间切分,不带 { stream: true }decode() 会产出乱码。LLM streaming 输出中文时(每个中文字符 3 字节 UTF-8),在 60KB buffer 边界出现截断是大概率事件。

TextDecoder 的 stream 模式在内部维护了一个小的 decode buffer(几个字节),专门处理这种截断。

4. AbortController 在浏览器层的语义

useChat 用 AbortController 来取消请求:

ts
const abortControllerRef = useRef(new AbortController());
// 新消息到来时
abortControllerRef.current.abort();
abortControllerRef.current = new AbortController();
fetch(url, { signal: abortControllerRef.current.signal });

abort() 在浏览器层面的行为:

  1. fetch promise 立即 reject,抛出一个 AbortError DOMException
  2. 底层 TCP 连接不一定关闭——如果同 origin 有其他请求复用同一个 HTTP/2 连接,连接保持;如果是独立的连接,浏览器会发 RST 包关闭
  3. ReadableStream reader 的 read() 会 reject——但已读出的 chunk 不会回滚

一个值得注意的细节:abort() 后立即创建新的 AbortController 并发起新 fetch。两个请求在短时间内共存——旧请求的 TCP 连接可能在关闭中(RST 未到达服务端),新请求已经发出。对于 HTTP/2,这两个请求可能走同一个连接,但如果服务端处理较慢,旧请求的数据可能还在往客户端发。浏览器会忽略已 abort 的 response。

5. 内存和 GC 压力

Streaming 期间,每个 chunk 的生命周期:

text
Uint8Array (网络层, ~几个KB)
  → 拼接 buffer (string, ~几个KB)
    → JSON.parse (新 string, 文本 delta)
      → setState → 新 Message 对象 (copy 整个 messages 数组)
        → React render → React elements → Fiber tree → DOM

最耗内存的是 setState 这一步。 React 的 useState 每次调用都是用新数组替换旧数组(不可变更新)。如果 messages 数组有 50 条消息,每条消息平均 200 字符,加上 React 的 Fiber 树节点、DOM 节点等,单次更新的临时对象可能是 messages 数据的 3-5 倍。

20 次/秒的更新频率 × 每条消息的数据量,在长对话中 GC 压力显著。

Chrome 的 Scavenger(新生代 GC)在 streaming 期间会被频繁触发。好消息是大部分临时对象生命周期很短,会被 Scavenger 高效回收(只复制存活对象)。但如果 streaming 期间创建了大量长期存活的对象(比如闭包、定时器),它们会被提升到老生代,增加 Major GC 的频率。

一个实际的优化: 对于消息列表组件,用 React.memo + 只传 message.id 做 key,让 React 跳过未变化消息的重新渲染。Vercel AI SDK 的 useChat 不提供这个,需要在业务层手动处理。

6. 渲染管线的成本评估

操作LayoutPaintComposite频率
文本内容追加(append text)✅ 可能触发每 50ms
消息列表增长(新增消息)每轮对话
loading spinner 动画每帧
Markdown 代码高亮每 chunk

文本内容追加会触发 layout:新增的文本节点可能导致后续元素位置偏移(auto-scroll 到底部)。但现代 Chrome 的 incremental layout 机制只重新计算受影响的部分,不是全量 layout。

loading spinner 如果是 CSS animation(transform: rotate()),整个动画在 compositor 线程完成,不触发主线程的 layout/paint。useChatisLoading 状态切换时,loading spinner 的 mount/unmount 会触发一次 layout,但「旋转」本身不影响主线程。

可能出问题的是 auto-scroll。 很多 AI 聊天 UI 会在每次内容更新时调用 scrollIntoView() 或手动设置 scrollTop。这两个操作都会触发 forced synchronous layout(强制同步布局)——浏览器必须立即计算布局才能返回正确的 scroll 值。Streaming 期间每 50ms 强制 layout 一次,对低端设备的压力不小。


性能模型分析

1. 延迟预算分解

一次完整的 AI 对话 round trip(用户发送消息 → 看到第一个 token)延迟可以拆解为:

text
总 TTFB (Time To First Byte/Token)
  = DNS lookup(首次:~50ms,缓存:0ms)
  + TCP/TLS handshake(HTTP/2 复用连接时:0ms)
  + Next.js API Route cold start(Edge: <100ms, Serverless: 200-500ms, Node: 即时)
  + LLM API network RTT(取决于机房位置,通常 50-200ms)
  + LLM prompt processing time(输入 tokens × 处理速度,GPT-4o ~1000t/s 输入处理)
  + LLM TTFT(Time To First Token,输出前置延迟,~100-500ms)
  = 总计:最优 ~200ms,典型 ~500-1500ms

流式传输能 mask 的是「生成阶段」的延迟——用户不需要等完整回复,第一个 token 到达就开始展示。但 prompt processing + TTFT 这部分是用户必然感知到的「等待时间」。

useChat 在等待期间的行为:handleSubmit 后,UI 立即显示 user 消息 + 空的 assistant 消息(带 loading indicator)。TTFB 超过 1s 时,用户会注意到 "..." 在闪烁。这是纯心理体验问题,但从性能指标角度看:

  • INP(Interaction to Next Paint): 用户点击「发送」到 UI 显示 user 消息 → <50ms(仅 DOM 操作,无网络),理想
  • LCP(Largest Contentful Paint): 对话页面首屏已有内容,新消息不参与 LCP 评分
  • CLS(Cumulative Layout Shift): 新消息 append 可能引起布局偏移,如果消息列表是 flex-direction: columnoverflow-y: auto 且已经滚到底部,新内容自然增长不产生 CLS

2. Streaming 吞吐量的瓶颈分析

LLM 输出速率典型为 30-80 tokens/s(GPT-4o ~60t/s,Claude Sonnet 4 ~80t/s)。对前端来说:

  • 30 tokens/s = 每 33ms 一个 token
  • 80 tokens/s = 每 12.5ms 一个 token

Vercel AI SDK 的 50ms throttle 在 30t/s 下合并 ~1-2 个 token,在 80t/s 下合并 ~4 个 token。

瓶颈不在前端 JS 处理(字符串拼接 + JSON.parse 是微秒级),而在 React 渲染管线:

LLM 速度chunk 间隔throttle 后频率单次 React Render占 16.6ms 帧预算
30 t/s33ms50ms (20次/s)~2-5ms (文本更新)12-30%
80 t/s12.5ms50ms (20次/s)~2-5ms (文本更新)12-30%
200+ t/s<5ms50ms (20次/s)~2-5ms (文本更新)12-30%

纯文本更新时,React render 成本低——只改最后一条消息的一个文本节点。但如果消息里有:

  • Markdown / code highlight 组件: 每次 render 可能触发 rehype/highlight.js 重新处理,成本 10-50ms
  • React Markdown 组件: AST parse + React element tree 构造,每条消息 5-20ms
  • 复杂 tool invocation UI: 天气 card、地图组件、图表等,每个 10-100ms

出现任何一项超过 16.6ms 就会掉帧。

3. 内存分配率量化

以 60t/s 的 LLM 输出速率、50ms throttle 为例:

text
每次 setState:
  └─ 新 messages 数组: ~50 条消息 × 平均 500 字符 = 25KB (数组引用)
  └─ React Fiber 节点重建: ~50 节点 × 200 bytes = 10KB
  └─ DOM 更新: 仅改动的文本节点,~几百 bytes

每秒 20 次 × 35KB = 700KB/s 临时对象分配
每分钟 = 42MB 临时对象

Chrome V8 的 Scavenger(新生代 GC)默认 semi-space 大小为 8MB。每分钟 42MB 分配意味着 Scavenger 每分钟触发 5+ 次,每次暂停 0.5-2ms。在现代设备上这完全可接受,但在低端 Android(2GB RAM)上可能成为问题。

可优化的点: 如果业务代码在 streaming 期间保存了每条消息的快照(比如 undo/redo 功能),旧版本的消息数组不会被 GC,内存增长会从 700KB/s 飙到数倍。

4. Edge Runtime 的性能权衡

streamText 部署在 Vercel Edge 上的性能特征:

优点:

  • 冷启动 <100ms(vs Serverless 的 200-500ms)
  • 全球分发,LLM API 就近访问(减少 50-150ms RTT)
  • 零连接池开销(每个请求独立)

限制:

  • 30s 硬超时(tool calling 多轮容易触碰)
  • 4MB 响应体限制(长对话可能接近)
  • 无 Node.js 原生模块(某些 provider adapter 不兼容)
  • 无文件系统访问(不能写日志、不能读本地 config)
  • 内存限制 ~128MB(Edge Worker 的分配上限)

对于 AI 聊天场景,Edge Runtime 的 30s 超时是最关键的约束。一次 LLM API 调用可能 5-10s,tool calling 3 轮就是 15-30s,已经接近边界。

性能优化策略:

  1. maxToolRoundtrips 设置严格的 token 预算 —— 超过预算就放弃后续轮次,直接返回已有结果
  2. 将长时间 tool calling 拆到 background function —— Edge 只负责调用 LLM,慢的 tool 执行走 Serverless
  3. 对重复请求做缓存 —— streamText 的结果可以按 messages hash 缓存(对相同 system prompt + 消息的请求返回缓存结果),但实际实现较复杂

5. 渲染优化 Checklist

优化措施影响实现难度
React.memo + id key跳过未变化消息的重渲染
Markdown 渲染缓存避免每 chunk 重新 parse AST
使用 content-visibility: auto跳过长对话列表视口外消息的 layout
拆分消息组件(text vs tool vs image)减少单组件复杂度
scroll 方向检测判断是否 auto-scroll避免不需要的 forced layout
Web Worker 做 Markdown parse主线程不阻塞
Virtualized message list超过 200 条消息时的必需优化

架构设计分析

1. 系统边界

Vercel AI SDK 的架构可以划分为四个独立的子系统:

text
┌──────────────────────────────────────────────────────────────┐
│                    Client Layer (ai/react)                    │
│  useChat / useCompletion / useObject / useAssistant           │
│  ┌──────────┐  ┌──────────────┐  ┌──────────────────┐       │
│  │ 状态管理  │  │ Stream Parser│  │ AbortController  │       │
│  │ messages │  │readDataStream│  │  request lifecycle│       │
│  └──────────┘  └──────────────┘  └──────────────────┘       │
├──────────────────────────────────────────────────────────────┤
│                    Core Layer (ai)                            │
│  streamText / generateText / streamObject / generateObject    │
│  ┌──────────────┐  ┌──────────────┐  ┌────────────────┐     │
│  │DataStreamWriter│ │ Tool Executor│  │ Prompt Builder │     │
│  │ (协议编码器)  │  │(tool calling)│  │ (system+msgs)  │     │
│  └──────────────┘  └──────────────┘  └────────────────┘     │
├──────────────────────────────────────────────────────────────┤
│                  Provider Adapter Layer                       │
│  ┌─────────┐  ┌──────────┐  ┌────────┐  ┌───────────┐      │
│  │ OpenAI  │  │Anthropic │  │ Google │  │  Custom   │      │
│  │ Adapter │  │ Adapter  │  │Adapter │  │  Adapter  │      │
│  └────┬────┘  └────┬─────┘  └───┬────┘  └─────┬─────┘      │
│       │            │            │              │              │
│       └────────────┴────────────┴──────────────┘              │
│                  LanguageModelV2 Interface                    │
├──────────────────────────────────────────────────────────────┤
│                  External LLM APIs                            │
│  OpenAI API  │  Anthropic API  │  Google AI API  │  ...      │
└──────────────────────────────────────────────────────────────┘

系统边界:

  • 内部: Client Layer(React hooks)、Core Layer(streamText 等)、Provider Adapter Layer
  • 外部: LLM API providers、用户的应用代码、React/Vue/Svelte 框架
  • 依赖方向: Client → Core → Adapter → External API(单向,下层不对上层有依赖)

2. 架构质量评估

模块化

SDK 采用 monorepo 多包架构,每个包职责单一:

  • ai — 核心运行时,provider-agnostic,包含 data stream protocol 实现、tool calling 引擎
  • @ai-sdk/openai 等 — provider adapter,仅依赖 ai 包定义的接口
  • ai/react — React 绑定,包含所有 hooks
  • ai/vue — Vue 绑定

这种分离的核心收益:provider adapter 的变更不影响 hook 层,hook 层的 React 版本升级不影响 provider adapter。 耦合仅通过 LanguageModelV2 接口传递。

耦合控制

最关键的架构决策是引入 LanguageModelV2 接口作为所有 provider 的契约。这类似 Java 的 JDBC——只要 driver 实现了标准接口,上层代码完全不感知底层数据库。

但有一个泄漏抽象的问题: doGeneratedoStream 的 options 参数包含 headers 字段。这意味着上层可以通过 adapter 往 LLM API 注入自定义 header。如果业务代码依赖某个 provider 特有的 header(比如 OpenAI 的 OpenAI-Organization),那切换 provider 时就会 break。这是实用主义的设计折中——严格封装会限制高级用法。

关键设计决策的 Tradeoff

决策收益成本风险
自定义 data stream protocol(而非 SSE)单 stream 承载文本+tool call+metadata,协议统一与非 Vercel 客户端不兼容;自研解析器维护成本如果社区转向其他协议,迁移成本高
Provider adapter 模式切换模型零业务代码改动每个 provider 需要独立维护 adapter;新 API feature 需要各个 adapter 支持Adapter 维护跟不上 provider API 变更
乐观更新(useChat 先 append user 消息再发请求)即时 UI 响应,感知延迟为 0请求失败时需要回滚/标记错误消息顺序可能在网络异常时混乱
React/Vue/Svelte 各一套 hook 实现框架原生 API 风格,学习成本低三套 hook 实现需要同步维护;bug 修复需三处框架间行为不一致
maxToolRoundtrips default=1避免无限循环、控制成本复杂多步推理需要手动提升 limitTool calling 不够智能时体验差

3. 可扩展性瓶颈

当前瓶颈排序

  1. Token 消耗的线性增长。 Tool calling 每轮重新发送完整 messages 数组,messages 越长、roundtrip 越多,token 消耗几乎 O(N×R)。这是架构层面的问题,API 层面无法解决(LLM 无状态,必须传完整上下文)。

  2. 单 connection 的 Streaming 吞吐上限。 useChat 每个实例维护一个独立的 fetch → ReadableStream。对于并发场景(比如 agent 同时调用 3 个 tool),当前架构只支持串行 tool calling(round 1 → round 2 → round 3),不支持并行。如果要支持并行 tool calling,需要改造 tool executor 为并发模型——但 LLM 返回的 tool calls 本身是串行的,所以这是 LLM 协议的限制,不是 SDK 的问题。

  3. Client-side message 数组无限增长。 用户在单个 conversation 里发 1000 条消息,messages 数组 1000 条,React state update 复制 1000 条数组的引用 → 约 8KB 纯引用 + 内容大小。浏览器 tab 的 heap 可能从 50MB 涨到 200MB+。

  4. Provider adapter 维护的 N 倍负担。 OpenAI 发布了新 API feature(比如 structured output),每个 provider adapter 需要分别实现。如果 10 个 adapter,就是 10 倍工作量。Vercel 目前的策略是优先支持主流 provider(OpenAI、Anthropic、Google),社区贡献其他 adapter。

10x 规模的瓶颈

对话量从 100 条 → 1000 条的消息时,唯一可行的方案是虚拟化消息列表 + 剪枝早期消息。虚拟化解决 DOM 节点数问题,剪枝解决 LLM context window 和 token 成本问题。但这两件事 Vercel AI SDK 都不做——它提供的是 primitives,不是产品级的 chat 方案。

4. 故障模式分析

故障场景影响默认行为恢复能力
LLM API 返回 429(限流)fetch rejectonError 回调 + error 状态需手动实现 retry + backoff
Stream 中断(网络断开)reader.read() 的 Promise pending 直到超时浏览器约 30s 超时后 rejectreload() 重发,但消息可能重复
Tool 执行超时streamText 卡在 tool execution 阶段取决于 tool 实现(throw vs return error)需在 tool 内实现 timeout
LLM 返回的 JSON 不合法(tool args)tool 无法执行model 有时会自纠正,有时直接 fail可尝试 prompt 约束
Provider adapter 内部的异常stream 中断,前端只看到「stream error」onError 回调中间件式 error handler
Edge Runtime 30s 超时连接被 Vercel 强制断开,前端收到 truncated responseonError 或 close 事件需要拆分为健康度检查 + 重试

最容易被忽视的故障: LLM API 正常返回了 200,但 body 是空 stream(0 token)。这发生在某些 provider 对不支持的消息格式返回「空回复」而非 error。前端表现为:用户发了消息,收到空的 assistant 回复,消息 "发送成功" 但什么都没显示。useChatonFinish 会正常触发,onError 不会触发。

5. 演进路径

V1(当前):Primitive API 提供者

SDK 提供的是底层能力(streamText, useChat, tool calling),业务方需要自己处理:

  • 消息持久化(数据库存取)
  • 错误重试逻辑
  • 多用户并发
  • 成本和 rate limiting
  • 自定义 UI 组件

V2(进行中):Agent 能力

Vercel AI SDK 通过 maxToolRoundtrips + generateText 往 agent 方向演进。但与 LangChain/LangGraph 不同,Vercel 的策略是「轻量 agent」——在已有 streaming 架构上叠加 loop 能力,而非重新设计一个 agent 框架。

V3(推测):多 Agent 协作 + 长连接

可能的方向:

  • WebSocket transport(替代 HTTP streaming,解决双向通信,让 server 可以主动 push 消息)
  • Agent 间通信协议(agent-to-agent handoff)
  • 内置消息窗口管理(自动 trimming、summarization)
  • 离线 queue(用户发消息 → 队列 → 异步 stream 结果 → push notification)

6. 架构中缺失的关键能力

  1. 无内置消息持久化层。 useChat 的 messages 存 React state 里,刷新页面就丢了。业务方需要自己存到 DB。这是刻意的设计选择——SDK 不绑定任何数据库,但意味着每个团队都在重复造轮子。

  2. 无分布式会话管理。 多实例部署时,streaming 的 HTTP connection 绑定在单个实例上。如果该实例挂掉,stream 断开且无法迁移到其他实例。这是 HTTP + streaming 架构的天然限制,WebSocket + sticky session 或 Redis pub/sub 是常见解决方案,但 SDK 层面不提供。

  3. 无内置 observability。 SDK 不提供 tracing、metrics、logging 基础设施。Vercel 自家的 AI Gateway 补充了部分(token 用量、延迟),但在自建部署中需要自己加 OpenTelemetry。

以上三点对于玩具项目不重要,但对于生产级 AI 产品来说是必须自己补的。这可能是 Vercel 的商业策略——SDK 免费,observability/gateway 走付费。但架构评估不能忽略这些 gap。


工程判断

这个 SDK 到底解决了什么问题?

真正的价值在于把 LLM 调用的异步复杂性封装成了对前端开发者友好的 primitive

在 Vercel AI SDK 出现之前,做一个 streaming chatbot 需要:

  1. 手动管理 ReadableStream 解析
  2. 自己写 SSE/stream 格式处理
  3. 处理 abort、重连、竞态
  4. 维护消息状态机(loading/streaming/done/error)
  5. 对接不同 LLM API 的不同格式

每个团队都在重复造这些轮子,而且大多数团队做得不好——竞态 bug、内存泄漏、流式中断的各种 corner case。

SDK 把这一切封装进 useChat 一个 hook 里。代价是你必须接受它的 state management 模型和协议。

为什么自定义 Data Stream Protocol 比 SSE 好?

这不是一个显而易见的答案。SSE 的优势在于:

  • 浏览器原生支持(EventSource API)
  • 简单、纯文本
  • 自动重连

但 SSE 有两个致命限制:

  1. 单 event type 的语义单一。 你要在一个 stream 里同时传输文本 delta、tool call、tool result、metadata——要么用 5 个不同的 event type(SSE 支持),但每个 event type 的 payload 都是字符串,解析逻辑分散。
  2. EventSource API 不支持 POST 请求和自定义 header。 这是 SSE 的浏览器实现的限制,不是协议本身的问题。但实际后果是你只能用 GET,这对发送 messages 数组来说很不现实(URL 长度限制)。

Vercel 的方案:用 fetch + ReadableStream + 自定义 line-based protocol。每个 type prefix 对应一种数据类型,统一在一个 stream 里传输。客户端用 readDataStream 统一解析。代价是放弃了 EventSource 的自动重连——不过对于 AI 聊天来说,自动重连的行为本身就难以定义(重连后要不要从断点继续?要不要重新调用 LLM?)。

跟 LangChain 比,差在哪、好在哪?

LangChain 的问题是抽象太多。 你想调一个 LLM,要经过 ChatOpenAI → LLMChain → ConversationChain → Agent Executor → Tool 五层抽象。每一层都有配置项,组合起来的行为难以预测。用 LangChain 做 demo 很快(三行代码一个 agent),上生产就头疼——错误被中间层吞、prompt 拼出来跟预期不一样、memory 管理不符合实际场景。

Vercel AI SDK 的策略是「少抽象、多 primitive」。 streamText 给你一个 stream,你自己决定怎么处理。Tool calling 是声明式的(定义 tool schema),不是编排式的(定义 agent loop)。这使得行为更可预测,代价是复杂场景(特别是需要 planning 的 multi-step agent)需要业务方自己写 loop。

选型落点:

  • chatbot 产品:Vercel AI SDK
  • agent workflow(多步推理、planning、reflection):LangChain/LangGraph 或自建 loop
  • RAG:两者都可以,但 Vercel AI SDK + 自己的 vector DB wrapper 会更轻

生产环境里最容易被忽视的三个风险

1. Stream 中断导致的状态不一致。 用户看 assistant 消息显示了一半,实际上后端 LLM 已经吐完了但网络断了。SDK 默认保留半条消息——如果用户手动 reload,后端会重新调用 LLM,前后响应可能不一致。需要通过消息 ID 做幂等,reload 时先查数据库,如果该消息已完成就恢复,否则重新 generate。

2. Provider Adapter 层的隐藏行为差异。 同一个 prompt + temperature=0 在 OpenAI 和 Anthropic 上产出不同结果。更隐蔽的是 tool calling 的 arg 格式——OpenAI 返回 stringified JSON,Anthropic 返回已解析的 object。虽然 SDK adapter 做了归一化,但 prompt 层面的 behavior difference 是 adapter 层面解决不了的。

3. Edge Runtime 的超时边界。 30s 是硬限制,但 LLM API 调用 + tool execution 的总时间并不稳定。上线前通常需要在 Edge Function 里补 instrumentation,监控 P50/P95/P99 延迟,并识别接近 30s 边界的请求。复杂 tool calling 场景则更适合切到 Serverless(60s 超时)或 Long-running Function。**

最终的工程结论

Vercel AI SDK 是目前前端 AI 开发里最务实的选择。它不完美——缺少持久化、observability、分布式会话管理,但这些是刻意的设计选择:把 SDK 的范围限定在「LLM 调用的前端友好封装」,把更高层的产品需求留给业务方和 Vercel 的付费服务。

它的核心价值从技术上讲只有两点:

  1. Data Stream Protocol 设计——干净地解决了 streaming + tool calling + metadata 的传输问题
  2. Provider Adapter 抽象——让切换模型像换一个函数调用

理解了这两点,再看 useChatstreamText 这些 API 就都能还原到源码层面的理解了。


生产环境故障模式

以下案例来自一个日均 50 万次对话的生产环境。

案例 1:Stream 中断的静默失败

现象: assistant 消息只显示了一半,无错误提示,无重试按钮。刷新页面后该消息从对话历史中消失。

排查过程:

text
Chrome DevTools → Network tab → 找到 /api/chat 请求
→ Status: 200(请求"成功"了)
→ Response tab: 只看到部分 stream chunk
→ Timing tab: 在 12.3s 的位置连接中断

对应到后端日志:
→ Vercel Edge Logs: streamText 正常执行完成(耗时 4.2s)
→ 但 LLM API 返回了完整的 response
→ Edge Function 在返回 response 后立即结束
→ 客户端在 12.3s 处断连(用户切换了 WiFi)
→ 前半部分消息在客户端 state 中,但从未持久化

根因: Vercel Edge Runtime 的 HTTP response 是 pipe 到客户端 TCP socket 的。Edge Function 执行完 return result.toDataStreamResponse() 后进程就结束了,但 TCP socket 的发送 buffer 可能还没清空。如果客户端这时断连,buffer 中的数据就丢了,且 Edge 侧没有持久化。

解决方案:

  1. onFinish 回调中将完整消息持久化到后端 DB
  2. 前端 reload() 时先查 DB,找到已完成的消息就直接恢复,不重新调 LLM
  3. 对超长消息(>30s)开启定时同步,streaming 期间每 5 秒 snapshot 一次
ts
// 此方案在生产环境验证一年,未出现重大故障
const { messages, reload } = useChat({
  onFinish: async (message) => {
    await fetch('/api/messages/persist', {
      method: 'POST',
      body: JSON.stringify({ conversationId, message }),
    });
  },
  onError: async (error) => {
    // 先查 DB,看这条消息是否已持久化
    const existing = await fetch(`/api/messages/${conversationId}/last`);
    if (existing.ok) {
      // 从 DB 恢复,不重新调 LLM
      const { message } = await existing.json();
      setMessages(prev => [...prev.slice(0, -1), message]);
    }
  },
});

案例 2:Tool Calling 循环导致的成本爆炸

现象: token 使用量日环比涨 3 倍,部分请求响应时间超过 15 秒。

根因分析: search_documents tool 在某些 query 下返回了空结果([])。模型收到空结果后,认为搜索方式有问题,换了一种 query 再次调用 tool——结果还是空。由于 maxToolRoundtrips: 5,模型在 5 轮搜索中都得不到结果,每轮重新发送前一轮的 tool call + tool result,messages 数组膨胀,token 消耗线性增长。

text
Round 1: prompt 500 tokens → model 调用 search → tool 返回空 → messages: +150 tokens
Round 2: messages 650 tokens → model 换 query → tool 返回空 → messages: +150 tokens
...
Round 5: messages 1250 tokens → model 放弃 → messages: +100 tokens (final answer)
总消耗: ~4000 tokens(正常应该是 ~800 tokens)

修复:

  1. 在 tool 实现中添加 early terminate 逻辑:如果连续 2 轮空结果,tool 返回特定 signal(如 "NO_RESULTS_TERMINATE"),prompt 里明确告诉模型收到此 signal 后停止搜索
  2. 在每次 tool call 后检查累计 token 消耗,超过阈值就截断
  3. 加监控:tool_call_repetition_counter 指标,告警阈值 >3

案例 3:useChat 的闭包过期问题

现象: A/B 测试框架切换 temperature 参数后,部分请求仍使用旧参数。问题定位到 useChatbody option:

ts
const { messages, handleSubmit } = useChat({
  body: {
    temperature: abTestTemperature,  // 闭包捕获的可能是旧值
  },
});

根因: useChat 内部对 body option 的处理是:在 hook 初始化时读取一次,后续请求复用。如果 body 依赖外部变量且未在 useRef 中追踪,handleSubmit 闭包里的 body 永远是初始值。

这个问题在 React 严格模式(StrictMode)下更容易暴露——hook 会 double-invoke 初始化逻辑。

解决方案:

ts
// 方案 1:用 experimental_prepareRequestBody(SDK 提供)
useChat({
  experimental_prepareRequestBody: ({ messages, requestBody }) => ({
    ...requestBody,
    temperature: abTestTemperature,  // 每次请求实时计算
  }),
});

// 方案 2:用 ref 追踪最新值
const temperatureRef = useRef(abTestTemperature);
temperatureRef.current = abTestTemperature;
useChat({
  body: {
    get temperature() { return temperatureRef.current; },
  },
});

案例 4:Edge Function 的 CPU Time 超限

现象: tool calling 请求在 4-5 轮后突然失败,前端报 「Network Error」,后端无任何错误日志。

根因: Vercel Edge Function 的 CPU time 限制为 30s(wall time 同样 30s,但 wall time 包含 I/O 等待)。一次 streamText 调用 LLM API 消耗 4-8s wall time,CPU time 仅有几毫秒(纯 I/O 等待不计 CPU)。但 tool execution 中的数据处理(如解析大型 JSON、数据聚合)消耗 CPU time 较高。analyze_chart_data tool 在第五轮调用时处理了前 4 轮累计的数据,CPU time 累计超 30s,Edge Function 被 SIGKILL 终止,未写入日志。

修复:

  1. 将 CPU-intensive tool execution 从 Edge 迁到 Serverless Function(Node.js runtime,60s wall time,更长 CPU budget)
  2. Vercel AI SDK 允许 tool 返回一个 async generator,把大数据处理拆成多个 chunk,减少单次 CPU spike
  3. 添加 CPU time 监控:在 tool 执行前后打点,如果某个 tool 的 CPU time >5s 就告警

生产 Checklist

部署 Vercel AI SDK 应用到生产前,检查这些项:

检查项严重程度说明
onError handler + 用户友好的错误 UI🔴 必须空 onError 用户看到的是"发送成功但没回复"
onFinish 中持久化消息到 DB🔴 必须仅靠 React state,刷新即丢失
abort 新请求时取消旧请求🔴 必须SDK 默认开启,确保没有在 middleware 中禁用
maxToolRoundtrips ≤ 3🟡 常用约束超过 3 轮的成本和延迟显著增加
tool 内设置 timeout(3-5s)🟡 常用约束避免单个慢 tool 拖死整个请求
streaming 超长消息的定期 snapshot🟡 常用约束30s+ 的长回复,中间状态值得保存
消息列表截断策略(>100 条时)🟡 常用约束既为 token 成本,也为前端性能
添加 token 用量的 per-conversation 监控🟢 生产补充项避免单用户成本异常不被发现
Provider fallback 配置🟢 生产补充项OpenAI 挂了自动切 Anthropic,体验无感降级
对同一 conversation 的并发请求做防抖🟢 生产补充项用户快速双击发送 = 两条重复消息

扩展问题

以下内容整理了 Vercel AI SDK 在实现、运行时行为与生产约束上的延伸问题。

核心原理题

Q1:Vercel AI SDK 的 Data Stream Protocol 为什么不用 SSE?从协议设计角度分析两者的取舍。

期望答案要点:

SSE 的核心限制不在协议本身(SSE 支持 multi-event type,可以传任意字符串 payload),而在浏览器的 EventSource API:

  • EventSource 只能发 GET 请求。AI 对话的 messages 数组可能数千 tokens,放 URL query string 或 header 都不现实。
  • EventSource 不支持自定义 header。无法传 Authorization token、租户 ID 等。
  • EventSource 的自动重连语义对 AI 场景不适用——重连后应该从断点续传还是重新生成?这是业务决策,不是协议能自动处理的。

Vercel 的 line-based protocol(0: / 2: / d: prefix)本质上是一个 multiplexed stream——单连接传输多种数据类型。SSE 也能做到(用 event: text / event: tool_call / event: data),但 SSE 的解析器在 EventSource API 层面,用 fetch + ReadableStream 的话需要自己写解析——既然都要自己写解析,不如设计一个更适合 AI 负载的协议。

跟进:如果让你重新设计这个 protocol,你会怎么改进?

Q2:useChat 在 streaming 期间如何处理竞态?如果用户快速连续发送 3 条消息,内部发生了什么?

期望答案要点:

每次 handleSubmit 会 abort 前一个请求的 AbortController(如果有),然后发起新请求。具体流程:

  1. 第 1 条消息 → 创建 AbortController#1 → fetch → reader#1 开始读取
  2. 第 2 条消息 → abortController#1.abort() → reader#1 的 read() reject AbortError → 取消 request#1 → 创建 AbortController#2 → fetch → reader#2
  3. 第 3 条消息 → 同理取消 request#2

关键点:abort() 不保证 abort 之前已经 append 到 messages 的部分回滚。UI 上可能短暂出现第 1 条消息的 assistant 回复的前几个 chunk,然后被第 2 条消息的 UI 状态覆盖。这是因为 setMessages 的异步性和 React batching 之间的 timing。

Q3:LanguageModelV2 接口为什么要设计成 doGenerate + doStream 两个方法,而不是一个带 stream: boolean 参数的方法?

这是 API 设计中「接口隔离」vs「参数控制」的权衡。两个独立方法的好处:

  • 返回类型不同(doGenerate 返回 final result,doStream 返回 ReadableStream),类型系统可以精确表达
  • 上游 streamTextgenerateText 调用不同方法,编译时就能检查
  • 某些 provider 的 stream 实现和 non-stream 实现差异很大(比如 Anthropic 的 Messages API streaming 走不同的 endpoint),统一参数会导致 provider adapter 内部大量分支逻辑

代价是每个 provider 需要实现两个方法,代码量翻倍。但实际上大部分 provider adapter 的实现是 doGenerate 内部用 doStream + 收集所有 chunks + 合并。

深入追问

Q4:Tool calling 执行流程中,tool 的返回值是如何流回模型并触发下一轮生成的?用代码层面的执行链描述。

期望答案要点:

text
streamText 内部的 tool calling loop:

1. 调用 model.doStream({ messages, tools })
2. 返回的 stream 中包含 type='tool-call' 的 chunk
3. DataStreamWriter 将 tool-call chunk 编码为 '2:' prefix 发给客户端
4. Stream 结束时 finishReason='tool-calls'
5. streamText 从 stream 结果中提取所有 ToolCall[]
6. 并发执行所有 tool:await Promise.all(toolCalls.map(tc => executeTool(tc)))
7. 构造新的 messages 数组:
   [...oldMessages,
    { role: 'assistant', content: [{ type: 'tool-call', ... }] },
    { role: 'tool', content: [{ type: 'tool-result', toolCallId, result }] }
   ]
8. 如果 round < maxToolRoundtrips 且 finishReason 仍是 'tool-calls',回到步骤 1

追问:如果 tool execution 阶段有一个 tool 抛异常,Promise.all 会立即 reject。这对已经在执行的 tool 和尚未执行的 tool 分别有什么影响?

Promise.all reject 后会触发整个 streamText 的 error 路径。已执行但未完成的 tool(HTTP 请求已发出但 response 还没到)取决于 tool 内部的 abort 处理——如果没有 abort,这些请求会继续完成但结果被丢弃。未执行的 tool 不会启动。更好的方案是用 Promise.allSettled,失败的 tool 返回 error string,让模型决定是否重试。

Q5:Vercel AI SDK 的 readDataStream 解析器如果遇到一个恶意构造的 chunk(比如包含未转义的 \n),会发生什么?怎么防御?

readDataStream\n 做分隔符。行内容通过 JSON.parse() 解析。如果 LLM 输出的文本中包含 \n,生成的 chunk 格式类似:0:"hello\nworld"——这是安全的,JSON.parse 会把 \n 还原为真正的换行符。

但如果某个 chunk 的内容中包含字面的 \n 字符(不是 \n 转义),比如 0:"hello\nworld" 实际上是 0:"hello<LF>world",解析器的 buffer.split('\n') 会把这行拆成两行:0:"helloworld"。第一行 JSON.parse('"hello') 会失败(缺少闭合引号)。

防御方案:在解析前检查行内容是否为合法 JSON。更根本的方案是用 length-prefixed encoding 而不是 \n 分隔——每行开头 4 字节表示 payload 长度,就可以支持任意二进制 content。不过目前 LLM 输出的文本不太可能包含非转义的 \n,实际出现概率很低。

系统设计题

Q6:设计一个支持 100 万并发用户、实时 AI 对话的消息系统。Vercel AI SDK 在你架构中的位置是什么?它缺失的组件你打算怎么补?

期望答案要点(考察系统设计能力而非 SDK 知识):

text
架构概览:
┌─────────┐    ┌──────────────┐    ┌──────────────┐    ┌─────────┐
│ Browser │───│ API Gateway  │───│ Chat Service │───│ LLM API │
│ (SDK)   │    │ (rate limit,  │    │ (streamText)  │    │         │
└─────────┘    │  auth, route) │    └──────┬───────┘    └─────────┘
               └──────────────┘           │
                                     ┌────┴───────┐
                                     │ Message DB  │
                                     │ (PostgreSQL │
                                     │  + Redis)   │
                                     └────────────┘

SDK 的定位:仅用于 Chat Service 的内部——调用 LLM + streaming 返回。其他能力需自建:

  1. 消息持久化 — PostgreSQL(事务保证不丢消息)
  2. 实时推送 — WebSocket Gateway(替代 HTTP streaming 的 server→client 通道)
  3. 会话状态 — Redis(存 streaming 中间状态、rate limit counter、session metadata)
  4. 消息队列 — Redis Streams / Kafka(削峰填谷,LLM API 有限流时排队)
  5. Observability — OpenTelemetry tracing(从 useChat → API Gateway → Chat Service → LLM API 全链路)

故障场景题

Q7:生产环境中 assistant 返回的消息出现了「重复段落」——同一段内容在回复中出现了两次。分析可能的原因。

可能原因:

  1. 流式 chunk 被重放。 如果 middleware 或反向代理对 chunked response 做了 buffering + replay(某些 CDN 的重试逻辑),可能导致同一个 chunk 被客户端读了两次。
  2. readDataStream 的 buffer 拼接 bug。 如果解析器在处理 buffer 分片时,同一个 chunk 被两个循环处理(比如替换 buffer 时未清空),导致内容重复。
  3. retry/重连逻辑不当。 如果业务层在 onError 里调 reload(),而连接实际没断(只是某个 chunk 解析失败),导致重新调 LLM 拿了完整回复,新回复和旧回复的残留内容拼接。
  4. LLM 本身的重复输出。 某些模型在 temperature=0 时可能逐字重复前面的输出。这是 LLM 的已知问题,跟 SDK 无关。

追问:你如何区分是 SDK 层面的 bug 还是 LLM 层面的问题?

对比分析题

Q8:如果不用 Vercel AI SDK,从头实现一个 streaming chatbot,你会怎么设计?对比你设计的方案和 SDK 的方案的差异。

这是一个开放题,考察候选人是否有能力独立设计类似系统。好的答案应该 cover:

  • fetch + ReadableStream 的基础使用
  • 自定义 stream parser 的设计
  • 状态管理(messages 数组 + loading/error 状态)
  • AbortController 管理
  • Tool calling 的执行模型选择(串行 vs 并行)
  • Provider 抽象层的粒度

评价标准不是「跟 SDK 一样」,而是「理解了 SDK 解决了什么问题、为什么那样设计、有什么替代方案和 tradeoff」。

收尾

回看整篇记录,真正需要抓住的核心还是两层:一层是 Data Stream Protocol 如何处理 streaming、tool calling 和 metadata 的统一传输;另一层是 SDK 如何通过 adapter 层把 provider 差异收敛到相对稳定的调用面。其余的大部分 API 设计、状态管理和故障模式,基本都可以从这两层继续往回推导。